락 없는 카운터 — GCC 원자적 연산 빌트인과 C11 atomic

NOTE

여러 스레드가 동시에 건드리는 정수 카운터를, 뮤텍스 없이도 안전하게 증가시키는 원자적 연산(atomic operation) 빌트인을 정리한다. GCC __sync_*/__atomic_* 빌트인과 C11 <stdatomic.h>를 비교한다.

0. 왜 필요한가

여러 스레드가 공유하는 요청 처리 카운터가 있다고 하면, 직관적으로는 이렇게 쓰고 싶어진다.

static int g_req_count = 0;
 
void on_request(void) {
    g_req_count++;   /* 여러 스레드가 동시에 부르면 안전하지 않다 */
}

g_req_count++은 한 줄이지만 실제로는 읽기(load) → 더하기(add) → 쓰기(store) 세 단계로 쪼개져서 실행된다. 스레드 A가 값을 읽은 직후, 아직 쓰기 전에 스레드 B가 끼어들어 같은 옛날 값을 읽고 더해서 쓰면, 두 번 증가해야 할 값이 한 번만 증가한 채 끝난다(레이스 컨디션). 뮤텍스로 감싸면 확실히 안전하지만, 단순 증가 연산 하나 때문에 매번 잠금/해제 비용을 치르는 건 과하다 — 이럴 때 쓰는 게 원자적 연산이다.

1. GCC 빌트인 — __sync_fetch_and_add

static int g_req_count = 0;
 
void on_request(void)
{
    __sync_fetch_and_add(&g_req_count, 1);   /* +1을 원자적으로(끊기지 않게) 수행 */
}
 
int old = __sync_fetch_and_add(&g_req_count, 1);
/* old에는 "더하기 전" 값이 담긴다 — 함수 이름 그대로 fetch(가져오기) 후 add(더하기) */

__sync_fetch_and_add(ptr, value)는 “읽기·더하기·쓰기”를 하나의 끊기지 않는 동작으로 CPU에게 보장받는다(대부분의 아키텍처에서 LOCK XADD 같은 전용 명령어로 구현된다). 뮤텍스처럼 다른 스레드를 잠깐 재우는 게 아니라, 하드웨어 수준에서 “이 명령어는 중간에 안 끼어든다”는 걸 보장하는 방식이라 훨씬 가볍다.

2. __atomic_* 빌트인과 C11 <stdatomic.h> — 표준화된 최신 방식

__sync_* 계열은 오래됐고 메모리 순서(memory order)를 세밀하게 지정할 수 없다. GCC 4.7 이상에서는 __atomic_* 빌트인을, C11부터는 아예 언어 표준에 포함된 <stdatomic.h>를 쓸 수 있다.

#include <stdatomic.h>
 
static atomic_int g_req_count = 0;   /* 일반 int 대신 atomic_int로 선언 */
 
void on_request(void)
{
    atomic_fetch_add(&g_req_count, 1);
}
 
int current = atomic_load(&g_req_count);   /* 읽기도 원자적으로 */
방식표준 여부특징
__sync_fetch_and_addGCC 확장(비표준)가장 오래됐고 이식성 있는 컴파일러가 많지만, 메모리 순서를 고를 수 없음(항상 가장 엄격한 순서로 동작)
__atomic_fetch_addGCC/Clang 확장메모리 순서(__ATOMIC_RELAXED 등)를 지정할 수 있어 세밀한 최적화 가능
atomic_fetch_add (<stdatomic.h>)C11 표준컴파일러 무관하게 이식 가능. 신규 코드라면 이 방식이 우선

3. 뮤텍스 대신 원자적 연산으로 충분한 경우 vs 아닌 경우

원자적 연산은 “값 하나”에 대한 단순한 연산(증가, 감소, 교환, compare-and-swap)까지만 보장한다. 여러 필드를 동시에 일관되게 바꿔야 한다면 그건 원자적 연산의 영역이 아니다.

typedef struct {
    int active_count;
    int total_count;
} stats_t;
 
static stats_t g_stats;
 
/* 이렇게 각각 원자적으로 바꿔도 "두 필드가 항상 같이 일관된 상태"는 보장되지 않는다 */
__sync_fetch_and_add(&g_stats.active_count, 1);
__sync_fetch_and_add(&g_stats.total_count, 1);
/* 두 호출 사이에 다른 스레드가 g_stats를 읽으면, active_count만 늘고 total_count는 아직 그대로인
   "중간 상태"를 볼 수 있다 */

active_count와 total_count를 항상 같이 늘려야 하는 불변조건이 있다면, 이 경우엔 뮤텍스로 두 필드를 함께 잠그는 게 맞다. 원자적 연산은 “변수 하나만 안전하면 되는 경우”의 최적화이지, 여러 값 사이의 일관성까지 보장해주는 도구가 아니다.

4. 자주 하는 실수

  • 여러 변수를 각각 원자적으로 바꾸고 전체가 일관될 거라 착각하기 — 3번 항목 그대로다. 개별 연산은 안전해도, 그 연산들 “사이”에 다른 스레드가 끼어드는 건 막지 못한다.
  • __sync_*와 __atomic_*를 섞어 쓰기 — 같은 변수에 대해 두 계열을 혼용하면 컴파일러가 가정하는 메모리 순서 모델이 달라져 예상 못 한 최적화가 끼어들 수 있다. 한 변수에는 한 계열만 일관되게 쓴다.
  • 일반 int를 그냥 __atomic_*/atomic_* 함수에 넘기면 다 안전해진다고 오해하기 — <stdatomic.h> 방식은 변수 자체를 atomic_int처럼 원자적 타입으로 선언해야 컴파일러와 하드웨어가 올바르게 정렬(alignment)과 접근 방식을 보장한다. 그냥 int에 atomic_fetch_add를 억지로 캐스팅해서 쓰는 건 정의되지 않은 동작이다.
  • 이식성을 고려 안 하고 최신 빌트인만 쓰기 — 오래된 GCC(4.7 미만)나 특정 임베디드 툴체인은 __atomic_*/<stdatomic.h>를 지원하지 않을 수 있다. 여러 컴파일러/플랫폼을 대상으로 하는 코드베이스라면 빌드 시스템에서 지원 여부를 확인하고 __sync_*로 폴백하는 조건부 컴파일이 필요할 수 있다(관련 패턴: 전처리기 조건부 컴파일).

관련 문서